เจาะลึก 'scan' ตัวช่วย Async Iterator ของ JavaScript สำรวจฟังก์ชัน, กรณีการใช้งาน และประโยชน์สำหรับการประมวลผลแบบสะสมค่าแบบอะซิงโครนัส
ตัวช่วย Async Iterator ของ JavaScript: Scan - การประมวลผลแบบสะสมค่าแบบอะซิงโครนัส
การเขียนโปรแกรมแบบอะซิงโครนัสเป็นรากฐานที่สำคัญของการพัฒนา JavaScript ในยุคปัจจุบัน โดยเฉพาะอย่างยิ่งเมื่อต้องจัดการกับการดำเนินการที่ต้องรอ I/O (I/O-bound operations) เช่น การร้องขอข้อมูลผ่านเครือข่ายหรือการโต้ตอบกับระบบไฟล์ Async iterators ซึ่งเปิดตัวใน ES2018 เป็นกลไกที่มีประสิทธิภาพสำหรับการจัดการสตรีมของข้อมูลแบบอะซิงโครนัส ตัวช่วย `scan` ซึ่งมักพบในไลบรารีอย่าง RxJS และเริ่มมีให้ใช้งานเป็นยูทิลิตี้เดี่ยวมากขึ้น ช่วยปลดล็อกศักยภาพที่มากยิ่งขึ้นสำหรับการประมวลผลสตรีมข้อมูลแบบอะซิงโครนัสเหล่านี้
ทำความเข้าใจ Async Iterators
ก่อนที่จะเจาะลึกเรื่อง `scan` เรามาทบทวนกันก่อนว่า async iterators คืออะไร Async iterator คืออ็อบเจกต์ที่สอดคล้องกับโปรโตคอล async iterator โปรโตคอลนี้กำหนดเมธอด `next()` ที่ส่งคืน promise ซึ่งจะ resolve เป็นอ็อบเจกต์ที่มีคุณสมบัติสองอย่าง: `value` (ค่าถัดไปในลำดับ) และ `done` (ค่าบูลีนที่ระบุว่า iterator สิ้นสุดแล้วหรือไม่) Async iterators มีประโยชน์อย่างยิ่งเมื่อทำงานกับข้อมูลที่มาถึงตามเวลา หรือข้อมูลที่ต้องการการดำเนินการแบบอะซิงโครนัสเพื่อดึงข้อมูล
นี่คือตัวอย่างพื้นฐานของ async iterator:
async function* generateNumbers() {
yield 1;
yield 2;
yield 3;
}
async function main() {
const iterator = generateNumbers();
let result = await iterator.next();
console.log(result); // { value: 1, done: false }
result = await iterator.next();
console.log(result); // { value: 2, done: false }
result = await iterator.next();
console.log(result); // { value: 3, done: false }
result = await iterator.next();
console.log(result); // { value: undefined, done: true }
}
main();
แนะนำตัวช่วย `scan`
ตัวช่วย `scan` (หรือที่เรียกว่า `accumulate` หรือ `reduce`) จะแปลง async iterator โดยใช้ฟังก์ชันสะสมค่า (accumulator function) กับแต่ละค่าและส่งค่าผลลัพธ์ที่สะสมออกมา ซึ่งคล้ายกับเมธอด `reduce` ของอาร์เรย์ แต่ทำงานแบบอะซิงโครนัสและบน iterators
โดยพื้นฐานแล้ว `scan` จะรับ async iterator, ฟังก์ชันสะสมค่า และค่าเริ่มต้น (optional) สำหรับแต่ละค่าที่ส่งมาจาก source iterator ฟังก์ชันสะสมค่าจะถูกเรียกโดยมีค่าที่สะสมไว้ก่อนหน้า (หรือค่าเริ่มต้นหากเป็นการวนรอบแรก) และค่าปัจจุบันจาก iterator เป็นอาร์กิวเมนต์ ผลลัพธ์ของฟังก์ชันสะสมค่าจะกลายเป็นค่าสะสมถัดไป ซึ่งจะถูกส่งออกจาก async iterator ที่เป็นผลลัพธ์
Syntax และ Parameters
syntax ทั่วไปสำหรับการใช้ `scan` มีดังนี้:
async function* scan(sourceIterator, accumulator, initialValue) {
let accumulatedValue = initialValue;
for await (const value of sourceIterator) {
accumulatedValue = accumulator(accumulatedValue, value);
yield accumulatedValue;
}
}
- `sourceIterator`: async iterator ที่จะทำการแปลง
- `accumulator`: ฟังก์ชันที่รับอาร์กิวเมนต์สองตัว: ค่าที่สะสมไว้ก่อนหน้าและค่าปัจจุบันจาก iterator ควรคืนค่าเป็นค่าสะสมใหม่
- `initialValue` (optional): ค่าเริ่มต้นสำหรับตัวสะสม หากไม่ระบุ ค่าแรกจาก source iterator จะถูกใช้เป็นค่าเริ่มต้น และฟังก์ชันสะสมค่าจะเริ่มถูกเรียกจากค่าที่สองเป็นต้นไป
กรณีการใช้งานและตัวอย่าง
ตัวช่วย `scan` มีความหลากหลายอย่างไม่น่าเชื่อและสามารถใช้ได้ในสถานการณ์ที่หลากหลายซึ่งเกี่ยวข้องกับสตรีมข้อมูลแบบอะซิงโครนัส นี่คือตัวอย่างบางส่วน:
1. การคำนวณยอดรวมต่อเนื่อง (Running Total)
ลองจินตนาการว่าคุณมี async iterator ที่ส่งค่าจำนวนเงินของธุรกรรมออกมา คุณสามารถใช้ `scan` เพื่อคำนวณยอดรวมต่อเนื่องของธุรกรรมเหล่านี้ได้
async function* generateTransactions() {
yield 10;
yield 20;
yield 30;
}
async function main() {
const transactions = generateTransactions();
const runningTotals = scan(transactions, (acc, value) => acc + value, 0);
for await (const total of runningTotals) {
console.log(total); // Output: 10, 30, 60
}
}
main();
ในตัวอย่างนี้ ฟังก์ชัน `accumulator` เพียงแค่บวกจำนวนเงินของธุรกรรมปัจจุบันเข้ากับยอดรวมก่อนหน้า `initialValue` ที่เป็น 0 ทำให้มั่นใจได้ว่ายอดรวมต่อเนื่องจะเริ่มต้นที่ศูนย์
2. การสะสมข้อมูลลงในอาร์เรย์
คุณสามารถใช้ `scan` เพื่อสะสมข้อมูลจาก async iterator ลงในอาร์เรย์ได้ ซึ่งมีประโยชน์สำหรับการรวบรวมข้อมูลเมื่อเวลาผ่านไปและประมวลผลเป็นชุด
async function* fetchData() {
yield { id: 1, name: 'Alice' };
yield { id: 2, name: 'Bob' };
yield { id: 3, name: 'Charlie' };
}
async function main() {
const dataStream = fetchData();
const accumulatedData = scan(dataStream, (acc, value) => [...acc, value], []);
for await (const data of accumulatedData) {
console.log(data); // Output: [{id: 1, name: 'Alice'}], [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}], [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Charlie'}]
}
}
main();
ในที่นี้ ฟังก์ชัน `accumulator` ใช้ spread operator (`...`) เพื่อสร้างอาร์เรย์ใหม่ที่ประกอบด้วยองค์ประกอบก่อนหน้าทั้งหมดและค่าปัจจุบัน `initialValue` คืออาร์เรย์ว่าง
3. การสร้างตัวจำกัดอัตรา (Rate Limiter)
กรณีการใช้งานที่ซับซ้อนขึ้นคือการสร้างตัวจำกัดอัตรา คุณสามารถใช้ `scan` เพื่อติดตามจำนวนคำขอที่เกิดขึ้นภายในกรอบเวลาที่กำหนดและหน่วงเวลาคำขอถัดไปหากเกินขีดจำกัดอัตรา
async function* generateRequests() {
// Simulate incoming requests
yield Date.now();
await new Promise(resolve => setTimeout(resolve, 200));
yield Date.now();
await new Promise(resolve => setTimeout(resolve, 100));
yield Date.now();
}
async function main() {
const requests = generateRequests();
const rateLimitWindow = 1000; // 1 second
const maxRequestsPerWindow = 2;
async function* rateLimitedRequests(source, window, maxRequests) {
let queue = [];
for await (const requestTime of source) {
queue.push(requestTime);
queue = queue.filter(t => requestTime - t < window);
if (queue.length > maxRequests) {
const earliestRequest = queue[0];
const delay = window - (requestTime - earliestRequest);
console.log(`Rate limit exceeded. Delaying for ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
yield requestTime;
}
}
const limited = rateLimitedRequests(requests, rateLimitWindow, maxRequestsPerWindow);
for await (const requestTime of limited) {
console.log(`Request processed at ${requestTime}`);
}
}
main();
ตัวอย่างนี้ใช้ `scan` ภายใน (ในฟังก์ชัน `rateLimitedRequests`) เพื่อรักษาคิวของเวลาที่เกิดคำขอ (timestamps) โดยจะตรวจสอบว่าจำนวนคำขอภายในกรอบเวลาที่จำกัดนั้นเกินจำนวนสูงสุดที่อนุญาตหรือไม่ หากเกิน ก็จะคำนวณเวลาที่ต้องหน่วงและหยุดชั่วคราวก่อนที่จะส่งค่าคำขอออกมา
4. การสร้างตัวรวบรวมข้อมูลแบบเรียลไทม์ (ตัวอย่างระดับโลก)
ลองพิจารณาแอปพลิเคชันทางการเงินระดับโลกที่ต้องการรวบรวมราคาหุ้นแบบเรียลไทม์จากตลาดหลักทรัพย์ต่างๆ Async iterator สามารถสตรีมการอัปเดตราคาจากตลาดต่างๆ เช่น ตลาดหลักทรัพย์นิวยอร์ก (NYSE), ตลาดหลักทรัพย์ลอนดอน (LSE), และตลาดหลักทรัพย์โตเกียว (TSE) `scan` สามารถใช้เพื่อรักษาราคาเฉลี่ยต่อเนื่องหรือราคาสูงสุด/ต่ำสุดสำหรับหุ้นตัวใดตัวหนึ่งในทุกตลาดได้
// Simulate streaming stock prices from different exchanges
async function* generateStockPrices() {
yield { exchange: 'NYSE', symbol: 'AAPL', price: 170.50 };
yield { exchange: 'LSE', symbol: 'AAPL', price: 170.75 };
await new Promise(resolve => setTimeout(resolve, 50));
yield { exchange: 'TSE', symbol: 'AAPL', price: 170.60 };
}
async function main() {
const stockPrices = generateStockPrices();
// Use scan to calculate a running average price
const runningAverages = scan(
stockPrices,
(acc, priceUpdate) => {
const { total, count } = acc;
return { total: total + priceUpdate.price, count: count + 1 };
},
{ total: 0, count: 0 }
);
for await (const averageData of runningAverages) {
const averagePrice = averageData.total / averageData.count;
console.log(`Running average price: ${averagePrice.toFixed(2)}`);
}
}
main();
ในตัวอย่างนี้ ฟังก์ชัน `accumulator` จะคำนวณผลรวมของราคาและจำนวนการอัปเดตที่ได้รับ จากนั้นราคาเฉลี่ยสุดท้ายจะถูกคำนวณจากค่าที่สะสมเหล่านี้ ซึ่งให้มุมมองแบบเรียลไทม์ของราคาหุ้นในตลาดต่างๆ ทั่วโลก
5. การวิเคราะห์ทราฟฟิกเว็บไซต์ทั่วโลก
ลองจินตนาการถึงแพลตฟอร์มวิเคราะห์เว็บระดับโลกที่รับสตรีมข้อมูลการเข้าชมเว็บไซต์จากเซิร์ฟเวอร์ที่ตั้งอยู่ทั่วโลก แต่ละจุดข้อมูลแทนผู้ใช้ที่เข้าชมเว็บไซต์ เราสามารถใช้ `scan` เพื่อวิเคราะห์แนวโน้มของจำนวนการดูหน้าเว็บ (page views) ต่อประเทศแบบเรียลไทม์ สมมติว่าข้อมูลมีลักษณะดังนี้: `{ country: "US", page: "homepage", timestamp: 1678886400 }`
async function* generateWebsiteVisits() {
yield { country: 'US', page: 'homepage', timestamp: Date.now() };
yield { country: 'CA', page: 'product', timestamp: Date.now() };
yield { country: 'UK', page: 'blog', timestamp: Date.now() };
yield { country: 'US', page: 'product', timestamp: Date.now() };
}
async function main() {
const visitStream = generateWebsiteVisits();
const pageViewCounts = scan(
visitStream,
(acc, visit) => {
const { country } = visit;
const newAcc = { ...acc };
newAcc[country] = (newAcc[country] || 0) + 1;
return newAcc;
},
{}
);
for await (const counts of pageViewCounts) {
console.log('Page view counts by country:', counts);
}
}
main();
ในที่นี้ ฟังก์ชัน `accumulator` จะอัปเดตตัวนับสำหรับแต่ละประเทศ ผลลัพธ์จะแสดงจำนวนการดูหน้าเว็บที่สะสมสำหรับแต่ละประเทศเมื่อมีข้อมูลการเข้าชมใหม่เข้ามา
ประโยชน์ของการใช้ `scan`
ตัวช่วย `scan` มีข้อดีหลายประการเมื่อทำงานกับสตรีมข้อมูลแบบอะซิงโครนัส:
- รูปแบบเชิงประกาศ (Declarative Style): `scan` ช่วยให้คุณสามารถแสดงตรรกะการประมวลผลแบบสะสมค่าในรูปแบบที่ชัดเจนและรัดกุม ซึ่งช่วยเพิ่มความสามารถในการอ่านและบำรุงรักษาโค้ด
- การจัดการแบบอะซิงโครนัส: มันจัดการการดำเนินการแบบอะซิงโครนัสภายในฟังก์ชันสะสมค่าได้อย่างราบรื่น ทำให้เหมาะสำหรับสถานการณ์ที่ซับซ้อนซึ่งเกี่ยวข้องกับงานที่ต้องรอ I/O
- การประมวลผลแบบเรียลไทม์: `scan` ช่วยให้สามารถประมวลผลสตรีมข้อมูลแบบเรียลไทม์ได้ ทำให้คุณสามารถตอบสนองต่อการเปลี่ยนแปลงที่เกิดขึ้นได้ทันที
- ความสามารถในการประกอบกัน (Composability): สามารถนำไปประกอบกับตัวช่วย async iterator อื่นๆ ได้อย่างง่ายดายเพื่อสร้างไปป์ไลน์การประมวลผลข้อมูลที่ซับซ้อน
การสร้าง `scan` (หากไม่มีให้ใช้)
แม้ว่าบางไลบรารีจะมีตัวช่วย `scan` มาให้ในตัว แต่คุณสามารถสร้างขึ้นเองได้อย่างง่ายดายหากจำเป็น นี่คือการสร้างแบบง่ายๆ:
async function* scan(sourceIterator, accumulator, initialValue) {
let accumulatedValue = initialValue;
let first = true;
for await (const value of sourceIterator) {
if (first && initialValue === undefined) {
accumulatedValue = value;
first = false;
} else {
accumulatedValue = accumulator(accumulatedValue, value);
}
yield accumulatedValue;
}
}
การสร้างนี้จะวนซ้ำผ่าน source iterator และใช้ฟังก์ชันสะสมค่ากับแต่ละค่า โดยส่งค่าผลลัพธ์ที่สะสมออกมา มันจัดการกรณีที่ไม่มี `initialValue` ให้มาโดยใช้ค่าแรกจาก source iterator เป็นค่าเริ่มต้น
การเปรียบเทียบกับ `reduce`
สิ่งสำคัญคือต้องแยกความแตกต่างระหว่าง `scan` กับ `reduce` แม้ว่าทั้งสองจะทำงานบน iterators และใช้ฟังก์ชันสะสมค่า แต่ก็มีความแตกต่างในพฤติกรรมและผลลัพธ์
- `scan` จะส่งค่าที่สะสมออกมาในทุกๆ การวนรอบ ซึ่งให้ประวัติการสะสมค่าอย่างต่อเนื่อง
- `reduce` จะส่งค่าที่สะสมสุดท้ายเพียงค่าเดียวหลังจากประมวลผลองค์ประกอบทั้งหมดใน iterator แล้ว
ดังนั้น `scan` จึงเหมาะสำหรับสถานการณ์ที่คุณต้องการติดตามสถานะระหว่างกลางของการสะสมค่า ในขณะที่ `reduce` เหมาะสมเมื่อคุณต้องการเพียงผลลัพธ์สุดท้ายเท่านั้น
การจัดการข้อผิดพลาด (Error Handling)
เมื่อทำงานกับ asynchronous iterators และ `scan` การจัดการข้อผิดพลาดอย่างเหมาะสมเป็นสิ่งสำคัญ ข้อผิดพลาดสามารถเกิดขึ้นได้ในระหว่างกระบวนการวนซ้ำหรือภายในฟังก์ชันสะสมค่า คุณสามารถใช้บล็อก `try...catch` เพื่อดักจับและจัดการข้อผิดพลาดเหล่านี้
async function* generatePotentiallyFailingData() {
yield 1;
yield 2;
throw new Error('Something went wrong!');
yield 3;
}
async function main() {
const dataStream = generatePotentiallyFailingData();
try {
const accumulatedData = scan(dataStream, (acc, value) => acc + value, 0);
for await (const data of accumulatedData) {
console.log(data);
}
} catch (error) {
console.error('An error occurred:', error);
}
}
main();
ในตัวอย่างนี้ บล็อก `try...catch` จะดักจับข้อผิดพลาดที่ถูกโยนมาจาก iterator `generatePotentiallyFailingData` จากนั้นคุณสามารถจัดการข้อผิดพลาดได้อย่างเหมาะสม เช่น การบันทึกข้อผิดพลาด (logging) หรือลองดำเนินการอีกครั้ง
สรุป
ตัวช่วย `scan` เป็นเครื่องมือที่มีประสิทธิภาพสำหรับการประมวลผลแบบสะสมค่าแบบอะซิงโครนัสบน JavaScript async iterators มันช่วยให้คุณสามารถแสดงการแปลงข้อมูลที่ซับซ้อนในรูปแบบที่ชัดเจนและรัดกุม จัดการการดำเนินการแบบอะซิงโครนัสได้อย่างเหมาะสม และประมวลผลสตรีมข้อมูลแบบเรียลไทม์ การทำความเข้าใจฟังก์ชันและกรณีการใช้งานของมันจะช่วยให้คุณสามารถใช้ `scan` เพื่อสร้างแอปพลิเคชันแบบอะซิงโครนัสที่แข็งแกร่งและมีประสิทธิภาพมากขึ้น ไม่ว่าคุณจะคำนวณยอดรวมต่อเนื่อง, สะสมข้อมูลลงในอาร์เรย์, สร้างตัวจำกัดอัตรา หรือสร้างตัวรวบรวมข้อมูลแบบเรียลไทม์ `scan` สามารถทำให้โค้ดของคุณง่ายขึ้นและปรับปรุงประสิทธิภาพโดยรวมได้ อย่าลืมพิจารณาการจัดการข้อผิดพลาดและเลือกใช้ `scan` แทน `reduce` เมื่อคุณต้องการเข้าถึงค่าสะสมระหว่างกลางในระหว่างการประมวลผลสตรีมข้อมูลแบบอะซิงโครนัสของคุณ การสำรวจไลบรารีอย่าง RxJS สามารถเพิ่มความเข้าใจและการประยุกต์ใช้ `scan` ในเชิงปฏิบัติภายใต้กระบวนทัศน์การเขียนโปรแกรมเชิงรับ (reactive programming paradigms) ได้ดียิ่งขึ้น